Docker 이미지 레이어와 빌드 캐시 이해하기
Docker 이미지 레이어와 빌드 캐시 이해하기
Docker 빌드는 Dockerfile을 위에서 아래로 실행하면서 각 명령의 결과를 캐시한다. 어느 단계의 입력이 달라져 cache miss가 발생하면 그 뒤 단계도 다시 만들어진다. 따라서 변경이 드물고 비용이 큰 작업을 앞에, 자주 바뀌는 소스 코드를 뒤에 배치한다. 다만 캐시는 “항상 최신”을 보장하는 장치가 아니다. build context를 줄이고, lock file을 기준으로 의존성을 설치하며, package download에는 BuildKit cache mount를 사용하고, 비밀값은 image layer나 build argument가 아닌 secret mount로 전달해야 한다.
목차
- #이미지는 파일 하나가 아니라 레이어의 조합이다
- #빌드 캐시는 어떤 입력을 비교할까
- #한 번 깨진 캐시는 뒤 단계까지 전파된다
- #소스보다 의존성 파일을 먼저 복사하기
- #Build Context를 작게 유지하기
- #Layer Cache와 Cache Mount는 목적이 다르다
- #패키지 설치 명령의 최신성 함정
- #비밀값을 캐시와 이미지에 남기지 않기
- #멀티 플랫폼 빌드에서는 캐시도 플랫폼별이다
- #CI에서 원격 캐시 공유하기
- #캐시와 재현 가능한 빌드를 구분하기
- #캐시 효율을 측정하고 원인을 찾기
- #실전 Dockerfile 구성
- #검증할 실패 조건
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
이미지는 파일 하나가 아니라 레이어의 조합이다
Dockerfile의 각 명령이 반드시 새로운 filesystem layer를 만드는 것은 아니지만, RUN, COPY, ADD처럼 파일시스템을 바꾸는 명령은 보통 이전 상태 위에 새로운 변경분을 쌓는다. 최종 이미지는 이 변경분들을 순서대로 합친 결과다.
FROM node:22-bookworm-slim
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
CMD ["node", "src/server.js"]
개념적으로는 다음과 같이 볼 수 있다.
flowchart BT
A[Base image filesystem]
B[WORKDIR metadata]
C[Manifest files]
D[node_modules]
E[Application source]
F[Runtime metadata]
A --> B --> C --> D --> E --> F여기서 중요한 점은 레이어가 단순한 “Dockerfile 한 줄의 실행 기록”이 아니라 다음 단계의 입력이라는 것이다. package-lock.json이 바뀌어 COPY 단계가 달라지면 그 위에서 실행하는 npm ci도 다시 실행되어야 한다. 반대로 애플리케이션 소스만 바뀌었는데 manifest와 의존성 설치 단계를 분리해 두었다면 기존 node_modules 결과를 재사용할 수 있다.
이미 만들어진 레이어에서 파일을 삭제한다고 이전 레이어의 데이터가 이미지 이력에서 사라지는 것도 아니다.
# 좋지 않은 예: 첫 RUN 결과에 archive가 이미 들어간다.
RUN curl -o toolkit.tar.gz https://example.invalid/toolkit.tar.gz
RUN tar -xzf toolkit.tar.gz
RUN rm toolkit.tar.gz
다운로드와 압축 해제를 하나의 RUN으로 묶으면 최종 변경분에 archive를 남기지 않을 수 있다.
RUN curl --fail --location \
--output /tmp/toolkit.tar.gz \
https://example.invalid/toolkit.tar.gz \
&& tar -xzf /tmp/toolkit.tar.gz -C /usr/local/bin \
&& rm /tmp/toolkit.tar.gz
이 글의 image tag, package 이름, registry 주소는 설명을 위한 가상 값이다. 실제 빌드에서는 신뢰할 수 있는 배포처와 checksum을 사용해야 한다.
레이어 수 자체를 무조건 줄이는 것이 목표는 아니다. 서로 다른 변경 주기와 책임을 하나의 거대한 RUN으로 합치면 캐시 재사용과 오류 분석이 더 어려워진다. “같이 성공하거나 실패해야 하고 같은 주기로 바뀌는 작업인가”를 기준으로 합친다.
빌드 캐시는 어떤 입력을 비교할까
빌더는 Dockerfile 명령을 위에서 아래로 보면서 이전 빌드 결과와 일치하는 cache record가 있는지 확인한다. 구체적인 cache key는 frontend와 BuildKit 버전에 따라 구현 세부사항이 달라질 수 있지만, 실무에서 필요한 규칙은 다음과 같다.
| 명령 | 주로 비교되는 입력 | 자주 하는 오해 |
|---|---|---|
FROM |
base image reference와 해석된 image | 같은 tag면 원격 최신 image를 매번 받는다고 생각함 |
RUN |
명령과 mount 등 실행 정의 | package repository 내용까지 자동 비교한다고 생각함 |
COPY, ADD |
대상 파일의 metadata/content 기반 checksum | Dockerfile 문자열만 같으면 hit라고 생각함 |
ARG |
이후 명령이 참조한 build argument | 값이 달라도 항상 같은 cache라고 생각함 |
| Secret mount | secret ID와 mount 속성 | secret 내용 변경이 자동으로 cache를 깨뜨린다고 생각함 |
예를 들어 다음 RUN은 어제와 오늘 명령 문자열이 같다.
RUN apt-get update && apt-get install -y curl
패키지 저장소의 curl 버전이 달라졌더라도 Docker가 원격 저장소의 현재 상태를 cache key에 넣어 비교하지는 않는다. 이전 cache record가 유효하면 명령을 다시 실행하지 않는다.
반면 COPY src ./src는 build context 안의 src 입력을 본다. 파일 내용이나 권한처럼 checksum에 참여하는 정보가 바뀌면 miss가 난다. Docker 공식 문서에 따르면 파일의 modification time만 달라진 경우에는 그것만으로 COPY cache가 무효화되지 않는다.
“외부 세계까지 포함해 결과가 최신이다”가 아니라 “빌더가 정의한 입력이 이전과 같다”는 뜻이다.
한 번 깨진 캐시는 뒤 단계까지 전파된다
레이어는 이전 단계 위에 쌓이므로 중간 단계가 달라지면 뒤 단계는 명령 문자열이 같아도 다른 부모를 갖는다.
flowchart LR
A[FROM hit] --> B[manifest COPY hit]
B --> C[npm ci miss]
C --> D[source COPY rebuild]
D --> E[test rebuild]소스 전체를 가장 먼저 복사하는 Dockerfile을 생각해 보자.
FROM node:22-bookworm-slim
WORKDIR /workspace
COPY . .
RUN npm ci
RUN npm test
README 한 줄만 고쳐도 COPY . .가 달라진다. 그러면 의존성 설치와 테스트가 모두 다시 실행된다. 결과는 맞을 수 있지만 변경량에 비해 빌드 비용이 크다.
캐시 친화적인 순서는 일반적으로 다음 조건을 따른다.
- 자주 바뀌지 않는 입력을 먼저 둔다.
- 비용이 큰 작업이 최소한의 입력에만 의존하게 만든다.
- 자주 바뀌는 소스와 metadata는 뒤로 보낸다.
- 서로 다른 변경 이유를 가진 작업을 별도 단계로 나눈다.
이 규칙은 절대적인 정렬 공식은 아니다. 예를 들어 보안 패치 때문에 base image를 자주 갱신해야 한다면 base 변경으로 전체 빌드가 다시 일어나는 것이 올바르다. 캐시 hit 비율보다 정확성과 최신성이 우선이다.
소스보다 의존성 파일을 먼저 복사하기
Node.js 애플리케이션이라면 의존성 graph를 결정하는 manifest와 lock file만 먼저 복사한다.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY src ./src
COPY config ./config
CMD ["node", "src/server.js"]
이제 변경의 영향 범위가 달라진다.
| 변경 | npm ci 재실행 |
소스 COPY 재실행 |
|---|---|---|
src/order.js 수정 |
아니오 | 예 |
package-lock.json 수정 |
예 | 예 |
.dockerignore로 제외된 로컬 로그 수정 |
아니오 | 아니오 |
| base image digest 변경 | 예 | 예 |
package.json만 복사하고 lock file을 빼면 정확한 dependency graph를 재현하기 어렵다. 반대로 lock file이 있는데 npm install로 범위를 다시 해석하는 것보다 CI 빌드에서는 npm ci처럼 lock file과 불일치할 때 실패하는 명령이 적합하다.
Monorepo는 더 섬세하다. 모든 package manifest를 무조건 복사하면 관련 없는 workspace의 변경도 cache를 깨뜨린다. 하지만 필요한 workspace manifest를 누락하면 설치 결과가 틀릴 수 있다.
COPY package.json package-lock.json ./
COPY packages/api/package.json packages/api/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN --mount=type=cache,target=/root/.npm \
npm ci --workspace packages/api \
--workspace packages/shared
실제 package manager가 workspace graph를 어떻게 해석하는지에 따라 root 설정과 추가 파일이 더 필요할 수 있다. 핵심은 “복사 줄을 적게 쓰기”가 아니라 설치 결과를 결정하는 입력을 빠짐없이, 그 외 입력은 넣지 않는 것이다.
Build Context를 작게 유지하기
Dockerfile에서 참조하지 않는 파일도 build context에 포함되면 client에서 builder로 전송될 수 있고, 잘못된 COPY . .의 입력이 된다. .dockerignore는 전송량과 cache invalidation 범위를 함께 줄인다.
.git
.idea
.vscode
node_modules
coverage
dist
*.log
.env
.env.*
!.env.example
.dockerignore는 비밀 관리 장치가 아니다민감한 파일을 context에서 빼는 방어선으로는 유용하지만, 실수로 규칙이 바뀔 수 있다. credential은 repository와 build directory에 두지 않고 secret manager에서 빌드 순간에 전달한다.
다음처럼 불필요하게 넓은 복사는 어떤 파일 때문에 cache가 깨졌는지 알기 어렵다.
COPY . /workspace
필요한 디렉터리를 명시하면 의존성이 문서화된다.
COPY src ./src
COPY migrations ./migrations
COPY package.json package-lock.json ./
단, .dockerignore의 패턴과 Dockerfile의 COPY 목록이 어긋나면 로컬에서는 존재하는 파일이 image에 없을 수 있다. CI에서 깨끗한 checkout으로 실제 container를 실행하는 smoke test가 필요하다.
Layer Cache와 Cache Mount는 목적이 다르다
Layer cache는 단계 전체가 정확히 일치할 때 결과를 통째로 재사용한다. Cache mount는 단계가 다시 실행되더라도 package manager의 다운로드 저장소 같은 일부 디렉터리를 재사용한다.
RUN --mount=type=cache,target=/root/.npm \
npm ci
lock file이 바뀌면 RUN npm ci layer는 다시 실행된다. 그래도 기존 npm cache에 같은 package archive가 있다면 네트워크에서 전부 다시 받지 않는다.
| 항목 | Layer cache | Cache mount |
|---|---|---|
| hit 조건 | 단계 입력 전체 일치 | mount에 이전 데이터 존재 |
| hit 시 명령 실행 | 실행하지 않음 | 실행함 |
| 대표 대상 | compile 결과, dependency layer | npm, apt, Gradle download cache |
| 최종 image 포함 | layer 결과는 포함 | mount 자체는 포함하지 않음 |
| 정확성 책임 | cache key | package manager와 lock/checksum |
Cache mount 안의 데이터는 믿을 수 없는 성능 보조물로 취급한다. 없어도 빌드는 성공해야 하고, 일부가 손상됐을 때 package manager가 checksum으로 검증하거나 다시 받아야 한다.
동시에 여러 빌드가 쓰는 package manager라면 locking mode도 검토한다.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates
BuildKit cache mount는 build syntax와 builder 지원이 필요하다. 팀의 local Docker, CI runner, remote builder가 같은 기능을 지원하는지 먼저 확인한다.
패키지 설치 명령의 최신성 함정
Cache가 잘 맞는 것과 최신 보안 패치를 받는 것은 다른 목표다. 다음 명령이 cache hit라면 repository index를 새로 확인하지 않는다.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates
해결책을 매 빌드마다 무조건 --no-cache로 만드는 것도 좋지 않다. 빌드 시간과 외부 저장소 의존성이 커지고, 같은 commit의 결과가 시점마다 달라질 수 있다.
운영 정책을 분리한다.
- 일반 commit 빌드는 기존 cache와 고정된 dependency 입력을 사용한다.
- 정기 rebuild는
--pull로 base image 갱신 여부를 확인한다. - 보안 패치 build는 필요한 stage의 cache를 의도적으로 무효화한다.
- 생성된 image는 digest와 SBOM, vulnerability scan 결과로 추적한다.
docker buildx build \
--pull \
--no-cache-filter runtime-packages \
--tag registry.example.invalid/sample-api:build-1042 \
.
--no-cache-filter 지원 여부는 사용하는 builder 버전에서 확인해야 한다. 무효화는 문제 해결용 주문이 아니라 “어떤 외부 입력을 새로 평가할 것인가”라는 배포 정책이어야 한다.
비밀값을 캐시와 이미지에 남기지 않기
private registry에서 package를 받기 위해 token이 필요하다고 하자. ARG나 ENV로 넣으면 image history, build metadata, 중간 layer에 노출될 수 있다.
# 나쁜 예
ARG PACKAGE_TOKEN
RUN echo "//registry.example.invalid/:_authToken=$PACKAGE_TOKEN" \
> /root/.npmrc \
&& npm ci
파일을 다음 RUN에서 지워도 이전 layer에는 남을 수 있다. BuildKit secret mount는 명령 실행 동안에만 secret을 보이게 한다.
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
npm ci
docker buildx build \
--secret id=npmrc,src=/secure/path/npmrc \
.
Docker 공식 문서 기준으로 secret의 내용이 바뀌었다고 cache가 자동으로 무효화되지는 않는다. secret ID와 mount path 같은 속성은 cache key에 참여할 수 있지만 값 자체의 교체를 dependency 결과 변경 신호로 기대하면 안 된다.
token 권한 변경 때문에 dependency 결과를 다시 평가해야 한다면 secret과 무관한 명시적 cache epoch를 사용한다.
ARG PRIVATE_DEPENDENCY_EPOCH=1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
echo "dependency epoch=${PRIVATE_DEPENDENCY_EPOCH}" \
&& npm ci
이 값은 비밀이 아니어야 한다. token 자체를 ARG에 넣어 cache busting 용도로 쓰면 안 된다.
멀티 플랫폼 빌드에서는 캐시도 플랫폼별이다
linux/amd64와 linux/arm64는 같은 소스라도 native dependency와 compile 결과가 다를 수 있다. 한 플랫폼에서 만든 artifact를 다른 플랫폼 runtime에 복사하면 실행 시 exec format error가 발생할 수 있다.
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS build
ARG BUILDPLATFORM
ARG TARGETPLATFORM
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY src ./src
RUN npm run build
FROM node:22-bookworm-slim
WORKDIR /workspace
COPY --from=build /workspace/dist ./dist
CMD ["node", "dist/server.js"]
순수 JavaScript bundle이라면 build platform에서 만든 결과를 여러 runtime에 사용할 수 있을 수 있다. 반면 native addon을 설치하거나 binary를 compile한다면 target platform을 명시하고 cross compilation 방식을 설계해야 한다.
Cache ID도 필요하면 target별로 나눈다.
RUN --mount=type=cache,id=npm-${TARGETPLATFORM},target=/root/.npm \
npm ci
이 설정을 넣는 것만으로 package manager cache가 완전히 안전해지는 것은 아니다. 실제 저장 형식이 플랫폼 독립적인지 확인한다.
CI에서 원격 캐시 공유하기
개발자 노트북의 local cache는 ephemeral CI runner에 없다. 매 job이 새 머신에서 시작한다면 cache export/import가 필요하다.
docker buildx build \
--cache-from type=registry,ref=registry.example.invalid/cache/sample-api:main \
--cache-to type=registry,ref=registry.example.invalid/cache/sample-api:main,mode=max \
--tag registry.example.invalid/sample-api:commit-abc123 \
--push \
.
원격 cache도 신뢰 경계를 가진다.
| 질문 | 확인할 내용 |
|---|---|
| 누가 쓸 수 있는가 | untrusted PR이 main cache를 덮어쓰지 못하게 분리 |
| 누가 읽을 수 있는가 | private source에서 나온 metadata 접근 제한 |
| key 수명은 얼마인가 | 오래된 cache 정리와 registry 비용 |
| fallback은 가능한가 | cache가 없어도 clean build 성공 |
| branch 간 공유할까 | main cache는 read-only fallback, branch cache는 별도 |
외부 기여자의 PR에서 실행되는 빌드가 production credential을 받거나 신뢰된 cache를 publish하면 supply-chain 경계가 흐려진다. CI event 종류와 repository 권한에 따라 secret, cache-to, push 권한을 분리한다.
배포 대상 image는 immutable tag나 digest로 별도 보존한다. cache manifest를 production artifact처럼 취급하지 않는다.
캐시와 재현 가능한 빌드를 구분하기
두 번의 빌드가 빠르게 끝나는 것과 같은 입력에서 같은 artifact를 만드는 것은 다르다.
재현성에 영향을 주는 외부 입력은 많다.
- floating base image tag
- lock 되지 않은 application dependency
- package repository의 시점별 상태
- build timestamp
- network에서 받은 checksum 없는 archive
- architecture와 compiler version
- locale과 time zone에 영향을 받는 build script
다음처럼 base image digest를 고정하면 해석 대상을 명확히 할 수 있다.
FROM node:22-bookworm-slim@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
위 digest는 형식 설명용 가상 값이다. 실제 digest는 검증된 registry에서 가져오고 자동 update 도구와 review 절차로 갱신한다.
고정은 업데이트를 없애는 게 아니라 업데이트 시점을 명시적으로 만든다. 정기적으로 새 digest를 제안하고, test와 scan을 거쳐 반영하는 흐름이 필요하다.
flowchart LR
A[Dependency update proposal] --> B[Clean build]
B --> C[Test]
C --> D[SBOM and scan]
D --> E[Image digest publish]
E --> F[Deploy by digest]캐시 효율을 측정하고 원인을 찾기
“CI가 느리다”는 느낌만으로 Dockerfile을 합치지 않는다. plain progress와 build record를 보고 어느 단계가 오래 걸리고 왜 miss가 났는지 확인한다.
docker buildx build \
--progress=plain \
--tag sample-api:diagnostic \
.
확인할 지표는 다음과 같다.
- build context 전송 크기
- 단계별 실행 시간
CACHED여부- dependency download byte
- 최종 image 크기
- clean build와 warm build 시간
- branch cache hit ratio
캐시 문제를 재현할 때는 세 가지 시나리오를 분리하면 좋다.
1. 완전히 깨끗한 builder에서 첫 build
2. 입력 변경 없는 두 번째 build
3. 소스 파일 하나만 바꾼 세 번째 build
두 번째에서 예상한 단계가 hit인지, 세 번째에서 dependency install이 유지되는지를 본다. lock file을 바꾼 네 번째 build에서는 install이 반드시 다시 실행되어야 한다.
실전 Dockerfile 구성
다음은 cache 구조를 보여 주기 위한 가상 TypeScript 서비스다. 실행 image 최소화는 다음 글에서 더 자세히 다룬다.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS dependencies
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,id=sample-npm,target=/root/.npm \
npm ci
FROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run test \
&& npm run build
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,id=sample-npm,target=/root/.npm \
npm ci --omit=dev \
&& npm cache clean --force
COPY --from=build /workspace/dist ./dist
USER node
CMD ["node", "dist/server.js"]
이 예시는 구조를 설명하지만 그대로 복사할 정답은 아니다.
- build와 runtime의 native dependency ABI가 호환되어야 한다.
npm ci를 두 번 하는 비용과 production dependency 분리 방법을 비교한다.- app이 쓸 디렉터리는
USER node전에 ownership을 준비한다. - health check와 signal 처리는 별도 runtime 요구사항으로 검증한다.
- image tag는 조직의 update와 pinning 정책에 맞춘다.
의존성 stage를 재사용하더라도 source가 바뀐 build stage만 다시 실행된다. runtime dependency는 manifest가 바뀔 때만 다시 설치된다.
검증할 실패 조건
정상적인 warm build 한 번으로 캐시 설계를 끝낼 수 없다.
- source만 바꿨을 때 dependency layer가 유지되는가
- lock file을 바꿨을 때 dependency install이 다시 실행되는가
.dockerignore대상 파일을 바꿨을 때 context checksum이 유지되는가- base digest를 바꿨을 때 필요한 하위 단계가 다시 만들어지는가
- build secret이
docker history와 최종 filesystem에 없는가 - untrusted PR이 write 가능한 shared cache를 사용하지 않는가
- cache 없이도 clean build가 성공하는가
- final image를 scan했을 때 build-only 도구가 남지 않는가
- amd64와 arm64 image가 각각 기동하는가
- registry cache가 없거나 일시적으로 실패해도 fallback하는가
- 동일한 commit을 다시 build했을 때 dependency와 artifact 차이를 설명할 수 있는가
구현 체크리스트
마무리
Docker build cache는 이전 결과를 무조건 믿는 임시 저장소가 아니라, 빌더가 정의한 입력이 같을 때 단계 결과를 재사용하는 dependency graph다. 중간 단계의 입력이 달라지면 그 뒤 단계도 새 부모 위에서 다시 만들어진다.
그래서 Dockerfile 순서는 읽기 취향이 아니라 변경 영향 범위를 결정한다. manifest와 lock file을 source보다 먼저 복사하고, build context를 필요한 파일로 제한하며, package download cache는 layer와 별도 mount로 관리한다.
동시에 cache hit는 최신성과 재현성을 보장하지 않는다. 외부 package repository, base image, secret 내용은 각자 다른 갱신 규칙을 갖는다. 일반 빌드의 속도, 정기 보안 rebuild, immutable artifact 발행을 서로 다른 정책으로 다뤄야 한다.
좋은 캐시 설계는 warm build가 빠른 데서 끝나지 않는다. cache가 전혀 없는 환경에서도 같은 입력으로 올바르게 build되고, secret이 남지 않으며, 어떤 변경이 어느 단계를 무효화했는지 설명할 수 있어야 한다.